<?php

namespace WDB\Structure;
use WDB,
    WDB\Exception,
    WDB\Exception\MalformedSerializedData;

/**
 * Base class for creating PHP structure. Properties defined by annotations can be accessed
 * as normal PHP properties like $object->propName.
 * 
 * Properties are defined via "property[-read|-write] type $name [description]" annotations.
 * Read-only properties can be set only in constructor and cannot be re-written later.
 * Write-only properties can be only written by external code.
 * Structure descendants can access properties without restriction through $data array.
 *
 * @author Richard Ejem <richard(at)ejem.cz>
 * @package WDB
 */
abstract class Structure extends \WDB\BaseObject
{

    /** @var array structure properties data */
    protected $data;

    /** @var WDB\Annotation\Property[] structure properties description */
    protected $properties;

    /** @var bool indicates that object is still being constructed so read only variables are writeable */
    private $constructing = TRUE;

    /**
     *
     * @return string serialized version of this object
     */
    public function serialize()
    {
        $s = $this->serializeArray($this->data);
        return $s;
    }
    
    protected function _serialize($indent)
    {
        $s = $this->serializeArray($this->data, $indent);
        return $s;
    }
    
    private function serializeArray($arr, $indent = 0)
    {
        $s = '';
        foreach ($arr as $key=>$val)
        {
            if (is_string($val))
            {
                $export = $this->serializeString($val);
            }
            elseif (is_int($val) || is_float($val))
            {
                $export = $val;
            }
            elseif (is_bool($val))
            {
                $export = $val ? 'T' : 'F';
            }
            elseif (is_null($val))
            {
                $export = 'N';
            }
            elseif (is_array($val))
            {
                $ser = $this->serializeArray($val, $indent+1);
                $export = 'a'.strlen($ser).":\n".$ser;
            }
            elseif (is_object($val))
            {
                if ($val instanceof Structure)
                {
                    $ser = $val->_serialize($indent+1);
                    $export = 's'.strlen($ser).':'.$this->serializeString(get_class($val)).":\n".$ser;
                }
                else
                {
                    $ser = serialize($val);
                    $export = 'o'.strlen($ser).':'.$ser;
                }
            }
            elseif (is_resource($val))
            {
                $ser = serialize($val);
                $export = 'r'.strlen($ser).':'.$ser;
            }
            else
            {
                throw new WDB\Exception\InvalidOperation("Cannot serialize an unknown type: ".gettype($val));
            }
            $s .= str_repeat(' ', $indent).(is_string($key) ? $this->serializeString($key) : $key).'='.$export."\n";
        }
        return $s;
    }
    
    private function serializeString($str)
    {
        return "'".str_replace(array('\\', "'"), array('\\\\', "\\'"), $str)."'";
    }
    
    protected function _unserialize($content)
    {
        return $this->unserializeArray($content);
    }
    
    private function unserializeArray($content)
    {
        $offset = 0;
        $values = array();
        while (true)
        {
            while ($offset < strlen($content) && ($content{$offset} == ' ' || $content{$offset} == "\n")) ++$offset; //skip leading whitespace indentation and empty rows
            if ($offset >= strlen($content)) break;
            $key = $this->unserializeValue($content, $offset);
            if ($content{$offset} != '=') throw new MalformedSerializedData ("= expected.");
            ++$offset;
            $values[$key] = $this->unserializeValue($content, $offset);
        }
        return $values;
    }
    
    private function unserializeValue($content, &$offset)
    {
        switch ($content{$offset}) //string key
        {
            case "'":
                if (!preg_match("~\'(?<str>(?:\\\\.|[^'\\\\])*)'~As", $content, $matches, NULL, $offset))
                {
                    throw new MalformedSerializedData("malformed string at offset $offset");
                }
                $offset += strlen($matches['str'])+2;
                return str_replace(array('\\\\', "\\'"), array('\\', "'"), $matches['str']);
            case 'T':
                 ++$offset;
                return TRUE;
            case 'F':
                ++$offset;
                return FALSE;
            case 'N':
                ++$offset;
                return NULL;
            case 'a':
                ++$offset;
                $len = $this->_unserGetLen($content, $offset);
                $this->_unserSemiColon($content, $offset);
                $u = $this->unserializeArray(substr($content, $offset, $len));
                $offset += $len;
                return $u;
            case 's':
                ++$offset;
                $len = $this->_unserGetLen($content, $offset);
                $this->_unserSemiColon($content, $offset);
                $className = $this->unserializeValue($content, $offset);
                $this->_unserSemiColon($content, $offset);
                $c = new $className(substr($content, $offset, $len));
                $offset += $len;
                return $c;
            case 'o':
            case 'r':
                ++$offset;
                $len = $this->_unserGetLen($content, $offset);
                $this->_unserSemiColon($content, $offset);
                $u = unserialize(substr($content, $offset, $len));
                $offset += $len;
                return $u;
            default:
                if (!preg_match("~[+-]?[0-9.,e]+~A", $content, $matches, NULL, $offset))
                {
                    throw new MalformedSerializedData("unknown token at offset $offset: $content");
                }
                $offset += strlen($matches[0]);
                return $matches[0];
        }
    }
    
    private function _unserSemiColon($content, &$offset)
    {
        if ($content{$offset} != ':') throw new MalformedSerializedData ("Expected colon at offset $offset");
        ++$offset;
    }
    
    private function _unserGetLen($content, &$offset)
    {
        if (!preg_match('~[0-9]+~A', $content, $m, NULL, $offset)) throw new MalformedSerializedData("unknown");
        $offset += strlen($m[0]);
        return $m[0];
    }
    
    public function copy($values = array())
    {
        $copy = clone $this;
        $copy->fetchArrayValues($values);
        return $copy;
    }
    
    /**
     *
     * @param array|string array of key=>value pairs to initialize structure properties
     * or serialized version of property object
     */
    public function __construct($values = array())
    {
        $this->data = array();

        //initialize properties from reflection - all anotated properties with no corresponding getXxx setXxx method
        $this->properties = array();
        $queue = array();
        $refl = new \ReflectionClass($this);
        array_push($queue, $refl);
        foreach ($refl->getInterfaces() as $interface)
        {
            array_push($queue, $interface);
        }
        do
        {
            $reflClass = array_pop($queue);
            $refl = WDB\Annotation\PhpDocReflection::fromReflObject($reflClass);
            
            if (!$reflClass->isInterface() || isset($refl->wdbStructureAnnotatiton) || isset($refl->wsa))
            {
                foreach ($refl->property as $prop)
                {
                    if (!method_exists($this, 'get' . ucfirst($prop->propName)) // not having getter or setter
                            && !method_exists($this, 'set' . ucfirst($prop->propName))
                            && !isset($this->properties[$prop->propName])) //not declared by descendant class (protection from duplicate Structure property declaration through inheritance)
                    {
                        $this->properties[$prop->propName] = $prop;
                    }
                }
            }
            if ($reflClass->isInterface())
            {
                foreach ($reflClass->getInterfaces() as $interface)
                {
                    array_push($queue, $interface);
                }
            }
            if ($reflClass->getParentClass() && $reflClass->getParentClass()->getName() !== __CLASS__)
            {
                array_push($queue, $reflClass->getParentClass());
            }
            
        } while ($queue);
        $this->fetchArrayValues($values);

    }
    
    private function fetchArrayValues($values = array())
    {
        $this->constructing = TRUE;
        if (!is_array($values) && ($v = $this->_unserialize($values)) !== FALSE)
        {
            $values = $v;
        }

        if (is_array($values))
        //initialize proeprties from associative array
        {
            //remove keys that have not valid property name
            foreach (array_keys($values) as $key)
            {
                if (!$this->_propertyExists($key))
                    unset($values[$key]);
            }

            //initialize data in parent
            parent::_initPropertiesFromArray($values);
        }
        else
        //initialize properties from argument list - argument order matches property annotation order in doc comment
        {
            throw new WDB\Exception\BadArgument("Structure initializer must be an array");
        }
        $this->constructing = FALSE;
    }

    /**
     * Get specified property value.
     *
     * @param string
     * @return mixed
     * @throws WDB\Exception\NotExistingProperty
     * @throws WDB\Exception\WriteOnlyProperty
     */
    public function __get($name)
    {
        if (!$this->_propertyExists($name))
            throw new WDB\Exception\NotExistingProperty($this, $name);
        if (parent::_propertyExists($name))
        {
            return parent::__get($name);
        }
        if (!$this->properties[$name]->read)
            throw new WDB\Exception\WriteOnlyProperty($this, $name);
        if (!isset($this->data[$name]))
            return NULL;
        return $this->data[$name];
    }

    /**
     * Set specified property value.
     *
     * @param string
     * @param mixed
     * @throws WDB\Exception\NotExistingProperty
     * @throws WDB\Exception\ReadOnlyProperty
     */
    public function __set($name, $value)
    {
        if (!$this->_propertyExists($name))
            throw new WDB\Exception\NotExistingProperty($this, $name);
        if (parent::_propertyExists($name))
        {
            parent::__set($name, $value);
        }
        if (!$this->constructing && !$this->properties[$name]->write)
            throw new WDB\Exception\ReadOnlyProperty($this, $name);
        $this->data[$name] = $value;
    }

    /**
     * Determines if property is set and is not null.
     *
     * @param string
     * @return bool
     */
    public function __isset($name)
    {
        return $this->_propertyExists($name) && $this->__get($name) !== NULL;
    }

    /**
     * Always throws logic exception - cannot unset structure property.
     *
     * @param string
     * @throws \LogicException
     */
    public function __unset($name)
    {
        throw new \LogicException("Structure property cannot be unset");
    }
    
    /**
     * Determines whether property exists (even if it is NULL unlike __isset()) in Structure or BaseObject logic.
     *
     * @param string
     * @return bool
     */
    public function _propertyExists($name)
    {
        return parent::_propertyExists($name) || isset($this->properties[$name]);
    }
    
    /**
     *
     * @return string human-readable list of properties and their values
     */
    public function dump()
    {
        $dump = '';
        foreach ($this->properties as $key=>$val)
        {
            $dump .= "$key => ".$this->dumpVar($this->data[$key])."\n";
        }
        return $dump;
    }
    
    /**
     * Sets property in extending class which is read only for public
     *
     * @param string
     * @param mixed
     */
    protected function _setReadonly($name, $value)
    {
        if (!$this->_propertyExists($name))
            throw new WDB\Exception\NotExistingProperty($this, $name);
        $this->data[$name] = $value;
    }
    
    /**
     * Prints debug information about one property.
     *
     * @param Structure $var
     * @return Structure 
     */
    private function dumpVar($var)
    {
        if ($var instanceof Structure)
        {
            return 'structure:'.$var->dump();
        }
        if (is_bool($var)) return $var ? 'bool:TRUE' : 'bool:FALSE';
        if (is_null($var)) return 'NULL';
        if (is_string($var)) return 'string:'.$var;
        if (is_numeric($var)) return 'number:'.$var;
        return WDB\Utils\System::debug($var, TRUE, FALSE);
    }
}
